Skip to content

TTS(Наконец-то?)#21

Draft
ReWAFFlution wants to merge 2 commits intoss14-art:masterfrom
ReWAFFlution:New-TTS
Draft

TTS(Наконец-то?)#21
ReWAFFlution wants to merge 2 commits intoss14-art:masterfrom
ReWAFFlution:New-TTS

Conversation

@ReWAFFlution
Copy link
Copy Markdown
Member

@ReWAFFlution ReWAFFlution commented Mar 23, 2026

Краткое описание

Почему мы должны добавить это?

Медиа (Видео/Скриншоты)

Проверочный пункт

  • Перед публикацией/запросом на проверку PR, я убедился что изменения работают.
  • Я добавил скриншоты/видео изменений, если только этот PR не изменит внутриигровую механику.
  • Я подтверждаю, что мои изменения лицензированы в соответствии с лицензией Open Space Лицензия и предоставляю разрешение на их использование в этом репозитории в соответствии с его условиями.

Changelog

🆑 GqXgji, Al-S

  • add: Добавлен TTS.

Summary by CodeRabbit

Новые функции

  • Новые функции
    • Добавлена полная система выбора и настройки голоса для персонажей с функцией предпросмотра
    • Добавлен контроль громкости речи в меню параметров аудио
    • Интегрирована поддержка голоса в систему радиосвязи
    • Расширена система маски голоса новыми возможностями выбора и предпросмотра голоса
    • Реализована инфраструктура генерации и воспроизведения синтезированной речи

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 23, 2026

📝 Walkthrough

Пояснение

Эта PR интегрирует функциональность Art-TTS (синтез текста в речь) в игру. Добавляет выбор голоса в редактор профиля и маски голоса, реализует серверную генерацию TTS с кэшированием и ограничением скорости, добавляет клиентскую систему воспроизведения TTS и расширяет системы радио и речи для поддержки синтеза голоса. Включает поддержку русского языка и каталог голосовых прототипов.

Изменения

Когорта / Файлы Сводка
Клиентские UI компоненты
Content.Client/Lobby/UI/HumanoidProfileEditor.xaml, Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs, Content.Client/Options/UI/Tabs/AudioTab.xaml, Content.Client/Options/UI/Tabs/AudioTab.xaml.cs
Добавлены UI элементы для выбора голоса (VoiceButton, VoicePlayButton), контейнер TTS с видимостью, зависящей от CVar конфигурации; интеграция ArtCVars для управления включением TTS и громкостью.
Управление голосами (клиент)
Content.Client/_Art/TTS/HumanoidProfileEditor.TTS.cs, Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml.cs, Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml, Content.Client/VoiceMask/VoiceMaskBoundUserInterface.cs
Логика выбора и предпросмотра голоса, загрузка прототипов TTSVoicePrototype, синхронизация состояния UI с профилем, обработка событий изменения голоса.
Система воспроизведения TTS (клиент)
Content.Client/_Art/TTS/TTSSystem.cs, Content.Client/_Art/TTS/ContentAudioSystem.cs
Управление воспроизведением потокового TTS аудио, очередь звуков по автору, очистка завершённых компонентов, расчёт параметров воспроизведения (громкость, дальность), обработка шёпота и обычной речи.
Серверное управление TTS
Content.Server/_Art/TTS/TTSManager.cs, Content.Server/_Art/TTS/TTSSystem.cs, Content.Server/_Art/TTS/TTSSystem.RateLimit.cs, Content.Server/_Art/TTS/TTSSystem.Sanitize.cs, Content.Server/_Art/TTS/TTSSystem.SSML.cs
HTTP клиент для генерации TTS, кэширование с LRU вытеснением, ограничение скорости на игрока, санитизация текста с транслитерацией и преобразованием чисел, SSML обёртывание с модификаторами речи.
Система маски голоса
Content.Server/VoiceMask/VoiceMaskSystem.cs, Content.Server/_Art/TTS/VoiceMaskSystem.TTS.cs, Content.Shared/VoiceMask/SharedVoiceMaskSystem.cs, Content.Shared/VoiceMask/VoiceMaskComponent.cs
Добавление поля VoiceId к компоненту маски, инициализация TTS, обработка событий трансформации голоса, обновление UI состояния с информацией голоса, валидация прототипов.
Профиль персонажа и предпочтения
Content.Shared/Humanoid/HumanoidProfileComponent.cs, Content.Shared/Humanoid/HumanoidProfileExportV1.cs, Content.Shared/Preferences/HumanoidCharacterProfile.cs, Content.Server/Preferences/Managers/ServerPreferencesManager.cs, Content.Server.Database/Model.cs, Content.Server/Database/ServerDbBase.cs
Добавление свойства Voice в компонент профиля, V1 экспорт и конверсия с сохранением голоса, случайный выбор голоса при создании профиля, валидация и фоллбэк к голосам по полу, сохранение в БД.
Интеграция с системой радио
Content.Server/Radio/RadioEvent.cs, Content.Server/Radio/EntitySystems/RadioSystem.cs, Content.Server/Radio/EntitySystems/HeadsetSystem.cs
Добавление параметра Voice к RadioReceiveEvent, извлечение VoicePrototypeId из источника, отправка TTSRadioPlayEvent при получении радиосообщения с голосом.
Общие события и компоненты
Content.Shared/_Art/TTS/PlayTTSEvent.cs, Content.Shared/_Art/TTS/RequestPreviewTTSEvent.cs, Content.Shared/_Art/TTS/ClientOptionTTSEvent.cs, Content.Shared/_Art/TTS/TTSRadioPlayEvent.cs, Content.Shared/_Art/TTS/TTSComponent.cs, Content.Shared/_Art/TTS/TransformSpeakerVoiceEvent.cs, Content.Shared/_Art/TTS/SharedVoiceMaskSystem.cs
Определение сетевых событий для воспроизведения/предпросмотра TTS, компонент для хранения голоса, событие трансформации голоса для реле инвентаря, сообщение UI для изменения голоса маски.
Конфигурация и прототипы
Content.Shared/_Art/CVars/ArtCVars.cs, Content.Shared/_Art/TTS/TTSVoicePrototype.cs, Content.Shared/_Art/TTS/TTSConfig.cs, Resources/_Art/TTS/tts-voices.yml
CVar определения для TTS (включение, URL API, таймауты, громкость, кэш, ограничения скорости), прототип голоса с ID/имя/говорящий/пол, конфигурационные константы (голоса по полу, дальности), каталог 1960+ строк с голосовыми профилями.
Инициализация сервера
Content.Server/Entry/EntryPoint.cs, Content.Server/IoC/ServerContentIoC.cs, Content.Shared/Inventory/InventorySystem.Relay.cs
Регистрация TTSManager в IoC контейнер, инициализация TTS при запуске сервера, добавление TransformSpeakerVoiceEvent в реле инвентаря.
Редактор профиля (промежуточное)
Content.Client/Lobby/UI/HumanoidProfileEditor.Appearance.cs
Вызов UpdateTTSVoicesControls() после обновления пола до обновления других элементов управления.

Диаграммы последовательности

sequenceDiagram
    actor User as Игрок
    participant UI as HumanoidProfileEditor UI
    participant Editor as HumanoidProfileEditor
    participant Profile as HumanoidCharacterProfile
    participant Server as Сервер
    participant DB as База данных

    User->>UI: Выбирает голос из VoiceButton
    UI->>Editor: SetVoice(newVoice)
    Editor->>Profile: WithVoice(newVoice)
    Editor->>Editor: IsDirty = true
    Editor->>UI: UpdateTTSVoicesControls()
    UI->>UI: Обновляет выбранный голос
    
    User->>Server: Сохраняет профиль
    Server->>Server: ConvertProfiles(profile)
    Server->>DB: Сохраняет profile.Voice
    DB-->>Server: OK
    Server-->>User: Профиль сохранён
Loading
sequenceDiagram
    actor User as Игрок
    participant UI as VoicePlayButton
    participant Editor as HumanoidProfileEditor
    participant TTSClient as TTSSystem (Client)
    participant Server as TTSSystem (Server)
    participant TTSMgr as TTSManager
    participant API as TTS API

    User->>UI: Нажимает кнопку предпросмотра
    UI->>Editor: PlayPreviewTTS()
    Editor->>TTSClient: RequestPreviewTTS(voiceId)
    TTSClient->>Server: RequestPreviewTTSEvent
    
    Server->>Server: Генерирует случайный текст
    Server->>Server: Проверяет ограничения скорости
    Server->>TTSMgr: ConvertTextToSpeech(speaker, text)
    
    TTSMgr->>TTSMgr: Проверяет кэш
    alt Кэш попадание
        TTSMgr-->>TTSMgr: Возвращает кэшированное аудио
    else Кэш промах
        TTSMgr->>API: HTTP GET с текстом и голосом
        API-->>TTSMgr: WAV аудио
        TTSMgr->>TTSMgr: Сохраняет в кэш
    end
    
    TTSMgr-->>Server: byte[] WAV данные
    Server->>TTSClient: PlayTTSEvent(data, sourceUid)
    TTSClient->>TTSClient: LoadAudioWav(data)
    TTSClient->>TTSClient: Воспроизведение звука
    User-->>User: Слышит голос
Loading
sequenceDiagram
    actor User as Отправитель
    participant Speech as Система речи
    participant Radio as RadioSystem
    participant TTS as TTSSystem (Server)
    participant TTSMgr as TTSManager
    participant Audio as TTSSystem (Client)

    User->>Speech: Отправляет сообщение по радио
    Speech->>Radio: SendRadioMessage(uid, message, voice)
    Radio->>Radio: Извлекает voice из TTSComponent
    Radio->>Radio: RadioReceiveEvent(message, voice, ...)
    
    Radio->>TTS: TTSRadioPlayEvent(message, voice, effect="radio")
    TTS->>TTS: Генерирует TTS для радио эффекта
    TTS->>TTSMgr: ConvertTextToSpeech(speaker, sanitized_text, "radio")
    TTSMgr->>TTSMgr: Проверяет кэш, обрабатывает запрос
    TTSMgr-->>TTS: byte[] WAV аудио
    
    TTS->>Audio: PlayTTSEvent(broadcast)
    Audio->>Audio: Воспроизводит с эффектом радио
    User-->>User: Слышит синтезированное сообщение
Loading

Оценка усилий при проверке кода

🎯 4 (Complex) | ⏱️ ~60 minutes

Предлагаемые метки

S: Untriaged, size/M, S: Needs Review

Стихотворение

🐰 Голосов каталог создан,
Синтез речи реализован!
Радио поёт и шепчет волшебно,
Маски голосов звучат прелестно ✨🎙️
TTS систему кролик создал,
И весь мир в игре голос обрел!

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.98% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive Заголовок 'TTS(Наконец-то?)' является неопределённым и не описывает суть изменений. Фраза 'Наконец-то?' (в переводе 'Наконец?') — это разговорное выражение, которое не несёт конкретной информации о содержании PR. Замените заголовок на конкретный и описательный, например: 'Добавить систему Text-to-Speech (TTS)' или 'Реализовать TTS с поддержкой голосов и предпросмотра'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (7)
Content.Shared/_Art/CVars/ArtCVars.cs-17-21 (1)

17-21: ⚠️ Potential issue | 🟡 Minor

Неверный XML-комментарий (копипаст).

Комментарий "URL of the TTS server API" не соответствует переменной TTSClientEnabled.

📝 Предлагаемое исправление
 /// <summary>
-/// URL of the TTS server API.
+/// Whether TTS is enabled on the client.
 /// </summary>
 public static readonly CVarDef<bool> TTSClientEnabled =
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Shared/_Art/CVars/ArtCVars.cs` around lines 17 - 21, Комментарий XML
над объявлением public static readonly CVarDef<bool> TTSClientEnabled
некорректен (копипаст — "URL of the TTS server API"); замените его на корректное
описание состояния этой булевой переменной, например кратко поясняющее, что
переменная включает/отключает TTS-клиент (например: "Enable/disable the TTS
client" или на русском "Включает/выключает TTS-клиент"), сохранив формат
<summary>... </summary> над TTSClientEnabled и не меняя саму сигнатуру
CVarDef<bool>.
Resources/_Art/TTS/tts-voices.yml-93-112 (1)

93-112: ⚠️ Potential issue | 🟡 Minor

Уточнение о статусе голосов в TTS-системе.

Файл содержит идентификаторы синтетических TTS-голосов, а не записанные голоса реальных людей. TTS-система генерирует речь из текста, а не воспроизводит подготовленные аудиофайлы. Хотя включение имён реальных политиков (Байден, Обама, Трамп) среди голосов потенциально может создать правовые вопросы относительно прав на персональность и использование имён известных личностей, контекст некоммерческого проекта (согласно LICENSE.TXT) и игровая среда значительно снижают этот риск. Рекомендуется добавить комментарий в код для уточнения парody/развлекательного характера этих голосовых идентификаторов.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Resources/_Art/TTS/tts-voices.yml` around lines 93 - 112, The YAML currently
lists synthetic TTS voice identifiers for "Джо Байден" (speaker/id: biden),
"Барак Обама" (speaker/id: obama) and "Дональд Трамп" (speaker/id: trump)
without clarifying they are synthetic/parody; add a brief comment near these
entries (or at the top of Resources/_Art/TTS/tts-voices.yml) stating that these
are synthetic TTS voices generated from text (not recorded audio), intended for
parody/entertainment in a non-commercial/game context and not representations of
real recorded performances, to reduce legal ambiguity and clarify intent.
Content.Client/Options/UI/Tabs/AudioTab.xaml-11-11 (1)

11-11: ⚠️ Potential issue | 🟡 Minor

Нарушение паттерна локализации.

Строки "Громкость TTS:" и "Включить ТТС" жёстко закодированы на русском языке, тогда как остальные элементы используют {Loc '...'} для поддержки локализации. Это нарушает консистентность и затрудняет перевод интерфейса.

🌐 Предлагаемое исправление
-<ui:OptionSlider Name="SliderVolumeTts" Title="Громкость TTS:"/> <!-- Art-TTS  -->
+<ui:OptionSlider Name="SliderVolumeTts" Title="{Loc 'ui-options-tts-volume'}"/> <!-- Art-TTS  -->
-<CheckBox Name="TtsClientCheckBox" Text="Включить ТТС"/> <!-- Art-TTS -->
+<CheckBox Name="TtsClientCheckBox" Text="{Loc 'ui-options-tts-enabled'}"/> <!-- Art-TTS -->

Не забудьте добавить соответствующие ключи в файлы локализации.

Also applies to: 19-19

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Client/Options/UI/Tabs/AudioTab.xaml` at line 11, В XAML убери жёстко
закодированные русские строки и замени их на локализуемые ресурсы, используя
шаблон {Loc 'KeyName'} для элементов SliderVolumeTts (Title) и соответствующего
чекбокса/элемента "Включить ТТС" (примерно на строке с именем, содержащим Tts
или EnableTts); добавь уникальные ключи (например Audio.VolumeTts и
Audio.EnableTts) в файлы локализации и обнови ресурсы перевода, чтобы сохранить
консистентность с остальными UI-элементами.
Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml.cs-90-92 (1)

90-92: ⚠️ Potential issue | 🟡 Minor

Сбрасывайте выбор, если голос больше не доступен.

Если voice не найден в _voices, окно оставляет прошлый VoiceSelector как есть. При повторном открытии окна или смене списка голосов UI начнёт показывать устаревший выбор, который уже не соответствует серверному состоянию. Здесь нужен явный reset/fallback перед поиском.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml.cs` around lines 90 -
92, Reset the selector before attempting to re-select the voice so stale UI
state is cleared: set the VoiceSelector selection to an empty/fallback state
(e.g., SelectedIndex = -1 or clear selection) prior to calling
_voices.FindIndex(...), then if voiceIdx != -1 call
VoiceSelector.Select(voiceIdx); ensure you reference the existing
variables/methods (_voices, voiceIdx, VoiceSelector.Select) and add the explicit
reset when the voice is not found so the UI shows a cleared/fallback choice
instead of the previous selection.
Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs-196-200 (1)

196-200: ⚠️ Potential issue | 🟡 Minor

Добавьте guard для UpdateTTSVoicesControls() или сделайте её безопасным no-op при выключенном TTS.

InitializeVoice() вызывается только под ArtCVars.TTSClientEnabled (строка 199), но UpdateTTSVoicesControls() вызывается без этого guard в SetProfile() (строка 300) и OnReset() (строка 380). Когда TTS отключён, _voiceList остаётся пустым, и UpdateTTSVoicesControls() очищает VoiceButton, но не заполняет его — результат: неконсистентное состояние UI (элемент управления существует, но всегда пуст). Либо добавьте защиту перед вызовами, либо убедитесь, что метод имеет ранний выход при отключённом TTS и не опирается на состояние из InitializeVoice().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs` around lines 196 -
200, The UpdateTTSVoicesControls method can be called when TTS is disabled which
leaves _voiceList empty and yields an inconsistent UI; either add a guard before
calling it in SetProfile and OnReset (check
configurationManager.GetCVar(ArtCVars.TTSClientEnabled) or TTSContainer.Visible)
or change UpdateTTSVoicesControls to be a safe no-op when TTS is off (early
return if TTS disabled or if _voiceList is null/empty) so it does not rely on
InitializeVoice having run; update references to InitializeVoice, SetProfile,
OnReset, _voiceList, and VoiceButton accordingly.
Content.Client/_Art/TTS/HumanoidProfileEditor.TTS.cs-50-55 (1)

50-55: ⚠️ Potential issue | 🟡 Minor

Потенциальный IndexOutOfRangeException при пустом списке голосов.

Если _voiceList пуст, вызов _voiceList[firstVoiceChoiceId] (или _voiceList[0] после упрощения) приведёт к исключению.

🛡️ Предлагаемое исправление с проверкой
     var voiceChoiceId = _voiceList.FindIndex(x => x.ID == Profile.Voice);
-    if (!VoiceButton.TrySelectId(voiceChoiceId) &&
-        VoiceButton.TrySelectId(firstVoiceChoiceId))
+    if (!VoiceButton.TrySelectId(voiceChoiceId) &&
+        _voiceList.Count > 0 &&
+        VoiceButton.TrySelectId(0))
     {
-        SetVoice(_voiceList[firstVoiceChoiceId].ID);
+        SetVoice(_voiceList[0].ID);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Client/_Art/TTS/HumanoidProfileEditor.TTS.cs` around lines 50 - 55,
The code in HumanoidProfileEditor.TTS.cs may throw IndexOutOfRangeException when
_voiceList is empty because it accesses _voiceList[firstVoiceChoiceId]; before
calling VoiceButton.TrySelectId and SetVoice, guard against an empty list (check
_voiceList.Count > 0 or _voiceList.Any()) and only call
VoiceButton.TrySelectId(…) or SetVoice(…) when there is at least one element;
update the block containing voiceChoiceId, firstVoiceChoiceId,
VoiceButton.TrySelectId and SetVoice to skip selection/setting (or use a safe
default) when _voiceList is empty.
Content.Server/_Art/TTS/TTSSystem.cs-66-72 (1)

66-72: ⚠️ Potential issue | 🟡 Minor

async void без await и отсутствие синхронизации для _ignoredRecipients.

  1. Метод помечен как async void, но не содержит await — модификатор async не нужен.
  2. Список _ignoredRecipients может модифицироваться из нескольких сетевых обработчиков без синхронизации.
♻️ Предлагаемое исправление
-private async void OnClientOptionTTS(ClientOptionTTSEvent ev, EntitySessionEventArgs args)
+private void OnClientOptionTTS(ClientOptionTTSEvent ev, EntitySessionEventArgs args)
 {
     if (ev.Enabled)
         _ignoredRecipients.Remove(args.SenderSession);
     else
         _ignoredRecipients.Add(args.SenderSession);
 }

Если требуется потокобезопасность, рассмотрите использование HashSet с блокировкой или ConcurrentDictionary.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Server/_Art/TTS/TTSSystem.cs` around lines 66 - 72, The handler
OnClientOptionTTS is marked async without any await and mutates the shared
_ignoredRecipients non-thread-safely; remove the async modifier and make the
method synchronous (change signature OnClientOptionTTS to a plain void handler)
and protect updates to _ignoredRecipients by either replacing the collection
with a concurrent structure (e.g., use a ConcurrentDictionary/ConcurrentHashSet
pattern keyed by args.SenderSession) or wrap Add/Remove in a lock using a
dedicated private readonly object (e.g., lock(_ignoredRecipientsLock) { ... });
ensure all code paths that modify or read _ignoredRecipients use the chosen
synchronization approach.
🧹 Nitpick comments (13)
Content.Shared/_Art/TTS/TTSRadioPlayEvent.cs (1)

1-3: Неиспользуемые директивы using.

Импорты Content.Shared.Speech, Robust.Shared.Prototypes и Content.Shared.Inventory не используются в этом файле.

♻️ Предлагаемое исправление
-using Content.Shared.Speech;
-using Robust.Shared.Prototypes;
-using Content.Shared.Inventory;
-
 namespace Content.Shared._Art.TTS;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Shared/_Art/TTS/TTSRadioPlayEvent.cs` around lines 1 - 3, Удалите
неиспользуемые директивы using из файла TTSRadioPlayEvent.cs: уберите строки с
using Content.Shared.Speech, using Robust.Shared.Prototypes и using
Content.Shared.Inventory; оставьте только необходимые пространства имен и
сохраните объявление класса/структуры TTSRadioPlayEvent без изменений.
Content.Shared/_Art/TTS/TTSVoicePrototype.cs (1)

1-1: Неиспользуемый импорт Content.Shared.Humanoid.

♻️ Предлагаемое исправление
-using Content.Shared.Humanoid;
 using Robust.Shared.Prototypes;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Shared/_Art/TTS/TTSVoicePrototype.cs` at line 1, Удалите
неиспользуемую директиву использования `using Content.Shared.Humanoid;` из файла
— она не применяется в коде класса/файла `TTSVoicePrototype` и должна быть
удалена чтобы устранить предупреждение о неиспользуемом импорте.
Content.Shared/_Art/CVars/ArtCVars.cs (1)

41-45: Значение по умолчанию TTSVolume = 0f означает выключенный звук TTS.

Возможно, это намеренно, но пользователи могут не понять, почему TTS не работает. Рассмотрите значение 0.5f или 1.0f по умолчанию.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Shared/_Art/CVars/ArtCVars.cs` around lines 41 - 45, The default
TTSVolume CVar is set to 0f which mutes TTS by default; update the CVar default
in the TTSVolume declaration (the CVarDef.Create call for TTSVolume) to a
sensible audible default such as 0.5f (or 1.0f if you prefer full volume) so
users have working TTS out of the box while still allowing them to lower it via
the CVar.
Content.Server/_Art/TTS/TTSSystem.SSML.cs (1)

21-21: Опечатка в названии: PitchVerylowPitchVeryLow.

Для согласованности с PascalCase рекомендуется переименовать в PitchVeryLow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Server/_Art/TTS/TTSSystem.SSML.cs` at line 21, Переименуй
перечисление-элемент PitchVerylow в PitchVeryLow для соответствия PascalCase:
обнови определение enum (в файле TTSSystem.SSML.cs) и все места использования
этого значения (ссылки в коде, сравнения, сериализация/десериализация и тесты)
так, чтобы вместо PitchVerylow использовалось PitchVeryLow; не забудь также
обновить любые строковые представления или документацию, зависящие от имени.
Resources/_Art/TTS/tts-voices.yml (1)

1-37: Закомментированный код генератора следует удалить.

Закомментированные скрипты Emacs Lisp и Python не должны находиться в файле ресурсов. Переместите их в отдельный каталог /scripts или удалите.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Resources/_Art/TTS/tts-voices.yml` around lines 1 - 37, The file contains two
commented generator scripts (an Emacs Lisp defun f block and a Python
"#!/usr/bin/env python3" voice generator) embedded in
Resources/_Art/TTS/tts-voices.yml; remove these commented blocks from the YAML
and either delete them or move them into a proper scripts folder (e.g.,
/scripts) as standalone files (retain the Emacs Lisp function name "f" and the
Python generator that reads "voices.json" when moving so they can be located and
restored if needed), and ensure the YAML contains only resource data after the
change.
Content.Shared/VoiceMask/VoiceMaskComponent.cs (1)

18-22: Рассмотрите использование ProtoId<TTSVoicePrototype> вместо string.

Поле VoiceId объявлено как string, в то время как в HumanoidProfileComponent аналогичное поле Voice использует ProtoId<TTSVoicePrototype>. Для согласованности типов и типобезопасности при работе с прототипами рекомендуется использовать ProtoId<TTSVoicePrototype>.

♻️ Предлагаемое изменение
-    [DataField]
-    [ViewVariables(VVAccess.ReadWrite)]
-    public string VoiceId = TTSConfig.DefaultVoice;
+    [DataField]
+    [ViewVariables(VVAccess.ReadWrite)]
+    public ProtoId<TTSVoicePrototype> VoiceId = TTSConfig.DefaultVoice;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Shared/VoiceMask/VoiceMaskComponent.cs` around lines 18 - 22, Поле
VoiceId в VoiceMaskComponent объявлено как string; замените тип на
ProtoId<TTSVoicePrototype> для согласованности с
HumanoidProfileComponent::Voice, обновите инициализацию (вместо
TTSConfig.DefaultVoice присвойте new
ProtoId<TTSVoicePrototype>(TTSConfig.DefaultVoice) или эквивалентный
метод-конструктор), добавьте необходимый using/импорт для ProtoId и
TTSVoicePrototype и проверьте/скорректируйте все места, где читают/пишут VoiceId
(например сравнения или сериализация), чтобы работать с
ProtoId<TTSVoicePrototype> вместо string.
Content.Server/Radio/EntitySystems/HeadsetSystem.cs (1)

113-123: Дублирование вызова Transform(uid).ParentUid.

Переменная parent уже вычислена на строке 105. На строке 120 повторно вызывается Transform(uid).ParentUid. Рекомендуется использовать существующую переменную parent.

♻️ Предлагаемое исправление
         // Art-TTS Start
         if (TryComp(parent, out ActorComponent? actor))
         {
             _netMan.ServerSendMessage(args.ChatMsg, actor.PlayerSession.Channel);
             if (args.Voice is string voice)
             {
                 var ev = new TTSRadioPlayEvent(args.Message, voice, GetNetEntity(uid), GetNetEntity(args.MessageSource));
-                RaiseLocalEvent(Transform(uid).ParentUid, ev);
+                RaiseLocalEvent(parent, ev);
             }
         }
         // Art-TTS End
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Server/Radio/EntitySystems/HeadsetSystem.cs` around lines 113 - 123,
The code duplicates Transform(uid).ParentUid even though parent was already
computed; inside the TryComp(actor) block where you create the TTSRadioPlayEvent
and call RaiseLocalEvent, replace the repeated Transform(uid).ParentUid call
with the existing parent variable (computed earlier), keeping the rest of the
logic (creating TTSRadioPlayEvent(args.Message, voice, GetNetEntity(uid),
GetNetEntity(args.MessageSource)) and calling RaiseLocalEvent(parent, ev))
intact so you no longer recompute the transform.
Content.Client/_Art/TTS/ContentAudioSystem.cs (1)

1-8: Документирование "магического числа" TtsMultiplier

Файл корректно расположен в Content.Client/_Art/TTS/, но использует namespace Content.Client.Audio — это правильный паттерн для partial-классов, так как основное определение ContentAudioSystem находится в Content.Client/Audio/.

Рекомендуется добавить комментарий для объяснения значения константы TtsMultiplier = 3f:

 using Content.Shared.Audio;
 
 namespace Content.Client.Audio;
 
 public sealed partial class ContentAudioSystem : SharedContentAudioSystem
 {
+    /// <summary>
+    /// Множитель громкости для TTS-аудио для нормализации воспринимаемой громкости.
+    /// </summary>
     public const float TtsMultiplier = 3f;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Client/_Art/TTS/ContentAudioSystem.cs` around lines 1 - 8, The
constant TtsMultiplier = 3f in the partial class ContentAudioSystem is a magic
number and should be documented; add a short explanatory comment or XML doc
summary above the TtsMultiplier declaration (referencing ContentAudioSystem and
TtsMultiplier) that describes what the multiplier means, its units/scale, why
the value 3f was chosen, and any implications for audio timing/volume so future
maintainers understand its purpose.
Content.Client/_Art/TTS/TTSSystem.cs (3)

45-50: Рассмотрите использование private вместо internal для _toDelete.

Поле _toDelete помечено как internal, но используется только внутри этого класса. Рекомендуется использовать private для лучшей инкапсуляции.

♻️ Предлагаемое исправление
-internal List<NetEntity> _toDelete = new();
+private readonly List<NetEntity> _toDelete = new();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Client/_Art/TTS/TTSSystem.cs` around lines 45 - 50, The field
_toDelete is declared internal but is only used inside this class; change its
accessibility to private by renaming its declaration from internal
List<NetEntity> _toDelete = new(); to private List<NetEntity> _toDelete = new();
and run a quick search for external references to _toDelete to ensure no
external usages break (if any exist, either keep internal or update those call
sites).

175-185: Рассмотрите вынесение магического числа 3.0f в константу.

Множитель 3.0f в расчёте громкости не имеет документации. Рекомендуется вынести его в именованную константу для ясности.

+/// <summary>
+/// Volume multiplier for TTS audio.
+/// </summary>
+private const float VolumeMultiplier = 3.0f;
+
 private float AdjustVolume(bool isWhisper)
 {
-    var volume = MinimalVolume + SharedAudioSystem.GainToVolume(_volume * 3.0f);
+    var volume = MinimalVolume + SharedAudioSystem.GainToVolume(_volume * VolumeMultiplier);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Client/_Art/TTS/TTSSystem.cs` around lines 175 - 185, В методе
AdjustVolume используется "магическое" число 3.0f при вычислении громкости (var
volume = MinimalVolume + SharedAudioSystem.GainToVolume(_volume * 3.0f));
вынесите этот множитель в именованную константу (например VolumeGainMultiplier
или DefaultGainScale) в области класса рядом с MinimalVolume/WhisperFade,
замените 3.0f на константу и добавьте короткий комментарий о назначении
константы; проверьте, что AdjustVolume, _volume, SharedAudioSystem.GainToVolume
и WhisperFade по-прежнему корректно работают с новой константой.

158-158: Рекомендуется использовать using для MemoryStream.

Хотя MemoryStream не содержит неуправляемых ресурсов, использование using является хорошей практикой для IDisposable объектов.

♻️ Предлагаемое исправление
-var audioStream = _audioLoader.LoadAudioWav(new MemoryStream(ev.Data));
+using var memoryStream = new MemoryStream(ev.Data);
+var audioStream = _audioLoader.LoadAudioWav(memoryStream);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Client/_Art/TTS/TTSSystem.cs` at line 158, Объявление MemoryStream
при вызове _audioLoader.LoadAudioWav(new MemoryStream(ev.Data)) не обёрнуто в
using; оберните создание MemoryStream в using (или используйте using var) чтобы
гарантировать Dispose после использования; местоположения для правки: вызов в
методе в классе TTSSystem.cs где создаётся переменная audioStream и вызывается
метод LoadAudioWav на _audioLoader.
Content.Client/_Art/TTS/HumanoidProfileEditor.TTS.cs (1)

38-48: Упростите логику выбора первого голоса.

Переменная firstVoiceChoiceId инициализирована значением 1, но условие if (firstVoiceChoiceId == 1) сразу устанавливает её в i (что равно 0 на первой итерации). Это эквивалентно простому использованию 0 как индекса по умолчанию.

♻️ Предлагаемое упрощение
     VoiceButton.Clear();

-    var firstVoiceChoiceId = 1;
     for (var i = 0; i < _voiceList.Count; i++)
     {
         var voice = _voiceList[i];

         var name = Loc.GetString(voice.Name);
         VoiceButton.AddItem(name, i);
-
-        if (firstVoiceChoiceId == 1)
-            firstVoiceChoiceId = i;
     }

     var voiceChoiceId = _voiceList.FindIndex(x => x.ID == Profile.Voice);
     if (!VoiceButton.TrySelectId(voiceChoiceId) &&
-        VoiceButton.TrySelectId(firstVoiceChoiceId))
+        VoiceButton.TrySelectId(0))
     {
-        SetVoice(_voiceList[firstVoiceChoiceId].ID);
+        SetVoice(_voiceList[0].ID);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Client/_Art/TTS/HumanoidProfileEditor.TTS.cs` around lines 38 - 48,
The logic around firstVoiceChoiceId is redundant: it's initialized to 1 then
immediately set to i on the first loop iteration; simplify by initializing
firstVoiceChoiceId to 0 (or remove it and use 0 directly) and drop the if check
inside the loop. Update the loop over _voiceList (where
VoiceButton.AddItem(name, i) is called) to rely on a default index of 0 for the
first voice choice and remove the conditional that references
firstVoiceChoiceId.
Content.Server/_Art/TTS/TTSSystem.cs (1)

74-89: Рекомендуется добавить обработку исключений в async void методах.

Методы async void не позволяют отслеживать исключения — они могут привести к необработанным ошибкам. Рекомендуется обернуть тело метода в try-catch с логированием.

♻️ Пример обработки исключений
 private async void OnRequestPreviewTTS(RequestPreviewTTSEvent ev, EntitySessionEventArgs args)
 {
+    try
+    {
         if (!_isEnabled ||
             !_prototypeManager.TryIndex<TTSVoicePrototype>(ev.VoiceId, out var protoVoice))
             return;

         if (HandleRateLimit(args.SenderSession) != RateLimitStatus.Allowed)
             return;

         var previewText = _rng.Pick(_sampleText);
         var soundData = await GenerateTTS(previewText, protoVoice.Speaker);
         if (soundData is null)
             return;

         RaiseNetworkEvent(new PlayTTSEvent(soundData, null), Filter.SinglePlayer(args.SenderSession));
+    }
+    catch (Exception ex)
+    {
+        Log.Error(ex, "Error processing TTS preview request");
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Server/_Art/TTS/TTSSystem.cs` around lines 74 - 89,
OnRequestPreviewTTS is an async void handler so exceptions can escape; wrap the
method body in a try/catch that catches Exception (or a more specific set) and
logs the error (including context like ev.VoiceId, args.SenderSession) before
returning, ensuring failures from GenerateTTS, _prototypeManager.TryIndex, or
RaiseNetworkEvent are logged; keep existing early returns (RateLimitStatus
check) but put the remaining logic (previewText selection, await GenerateTTS,
and RaiseNetworkEvent) inside the try block and log the exception in the catch
(include method name OnRequestPreviewTTS and relevant identifiers).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Content.Client/_Art/TTS/HumanoidProfileEditor.TTS.cs`:
- Around line 16-20: В коде формирования _voiceList последовательный вызов
.OrderBy() перезаписывает первую сортировку; замените второе .OrderBy(...) на
.ThenBy(...) (или поменяйте порядок вызовов, если нужно сначала по полю Gender,
потом по имени) при перечислении прототипов через _prototypeManager. Убедитесь,
что ссылки на TTSVoicePrototype.Name (через Loc.GetString) и
TTSVoicePrototype.Gender используются в корректных лямбдах для первичной и
вторичной сортировки соответственно.

In `@Content.Client/_Art/TTS/VoiceMaskNameChangeWindow.xaml.cs`:
- Around line 30-34: The current sorting chain on _voices uses two OrderBy calls
so the second replaces the first; change the second OrderBy to ThenBy so the
list is primarily ordered by Loc.GetString(o.Name) and secondarily by the gender
expression — update the LINQ chain that starts with
_proto.EnumeratePrototypes<TTSVoicePrototype>() and the two OrderBy(...) calls
(the one ordering by name and the one ordering by the gender bitmask) to use
ThenBy for the secondary sort.
- Around line 24-29: Подписка на VoiceSelector.OnItemSelected внутри
ReloadVoices() приводит к множественным обработчикам при повторных вызовах;
переместите подписку в конструктор класса или гарантируйте идемпотентность
(например, отписаться перед новой подпиской или проверять, что обработчик ещё не
добавлен) и оставьте внутри обработчика вызовы VoiceSelector.SelectId(args.Id) и
проверку VoiceSelector.SelectedMetadata с вызовом
OnVoiceChange?.Invoke((string)VoiceSelector.SelectedMetadata).

In `@Content.Server.Database/Model.cs`:
- Line 334: Добавлено не-nullable свойство Profile.Voice, но нет миграции EF для
добавления соответствующего столбца в БД; создайте и примените миграцию Entity
Framework которая добавляет столбец Voice в таблицу профилей (либо сделать
свойство nullable и миграцию для nullable-столбца, либо добавить non-nullable
столбец с подходящим значением по умолчанию), затем выполнить обновление БД
(например через «dotnet ef migrations add» и «dotnet ef database update») чтобы
схема БД соответствовала модели Profile.Voice.

In `@Content.Server/_Art/TTS/TTSManager.cs`:
- Around line 40-42: The deduplication is not thread-safe: replace the
Dictionary<string, SemaphoreSlim> _semaphores usage with a ConcurrentDictionary
and use GetOrAdd (or ConcurrentDictionary<string, SemaphoreSlim>.GetOrAdd) to
ensure a single SemaphoreSlim per cacheKey atomically (fix the current
_semaphores.GetValueOrDefault(...) + _semaphores[cacheKey] = ... race). Protect
mutations of _cache and _cacheKeysSeq by either switching to concurrent
collections (e.g., ConcurrentDictionary for _cache and a thread-safe structure
for ordering) or by serializing access with a private lock object so the
add/evict sequence is atomic (fix parallel modifications at the
_cache/_cacheKeysSeq block). When cleaning up the semaphore in the finally
block, only remove/dispose it if you successfully removed the exact instance
(use ConcurrentDictionary.TryRemove with the expected instance) so you don't
race with a new requester. Update ResetCache() to also clear/ dispose
_semaphores to avoid inconsistent state. Finally, ensure CancellationTokenSource
and HttpResponseMessage are disposed (use using statements or explicit Dispose
in finally) where they are created.
- Around line 92-113: Обратите внимание, что CancellationTokenSource (cts) и
HttpResponseMessage (response) не освобождаются при ранних return — оберните
создание cts и получение response в using/using var (или try/finally с
.Dispose()) чтобы гарантировать вызов Dispose() при любом выходе; конкретно
измените создание и использование CancellationTokenSource и вызов
_httpClient.GetAsync(...) (переменные cts и response в TTSManager.cs) так, чтобы
response был в using-блоке и чтение response.Content.ReadAsByteArrayAsync()
происходило внутри этого блока перед возвратом данных, а при ошибочных кодах
сразу возвращали null после автоматического освобождения ресурсов.

In `@Content.Server/_Art/TTS/TTSSystem.cs`:
- Around line 150-155: The code computes a linear distance into variable
distance via .Length() but compares it to SharedChatSystem.VoiceRange *
SharedChatSystem.VoiceRange (a squared value), causing incorrect range checks;
fix by making the comparisons consistent: either compute squared distance (use
the vector delta's LengthSquared or equivalent) and compare to
VoiceRange*VoiceRange and WhisperClearRange*WhisperClearRange, or keep distance
as linear and compare to VoiceRange and WhisperClearRange directly; update the
checks around xformQuery/GetWorldPosition -> distance and the RaiseNetworkEvent
call (obfTtsEvent vs fullTtsEvent) accordingly so both comparisons use the same
metric.

In `@Content.Server/_Art/TTS/TTSSystem.Sanitize.cs`:
- Around line 311-321: The declension call in NumberToText is using (int)(value
% 10) which loses the tens place and causes wrong forms for 11–14; update the
call in NumberToText to pass (int)(value % 100) to GetDeclension so the function
can correctly detect 11–14 exceptions (modify NumberToText where it currently
calls GetDeclension and replace the modulus 10 with modulus 100).
- Around line 27-29: Сейчас порядок вызовов делает правила замены из Latin
(например ключи "id", "gps", "c4") недостижимыми, потому что предыдущие вызовы
(например Dygraphs.Replace и MatchedWord.Replace) уже изменяют текст;
переместите вызов Latin.Replace(text, ReplaceLat2Cyr) так, чтобы он выполнялся
до тех вызовов, которые превращают латинские последовательности в кириллические
(то есть вызывать Latin.Replace перед Dygraphs.Replace и MatchedWord.Replace), и
оставьте ReplaceLat2Cyr/ReplaceMatchedWord как есть.

In `@Content.Server/_Art/TTS/TTSSystem.SSML.cs`:
- Around line 6-14: The ToSsmlText method embeds the raw text into SSML without
escaping XML-special characters; update it to escape the input before wrapping
with prosody/speak (e.g., use System.Security.SecurityElement.Escape or create
an XText to produce a safe string) so '<', '>', '&', '"' and '\'' are encoded;
apply the escape to the initial text variable (used by ToSsmlText) and then
preserve the existing SoundTraits checks (RateFast, PitchVerylow) and prosody
wrapping logic so the escaped text is what gets inserted into the <prosody> and
final <speak> output.

In `@Content.Server/_Art/TTS/VoiceMaskSystem.TTS.cs`:
- Around line 16-19: В обработчике OnSpeakerVoiceTransform для
Entity<VoiceMaskComponent> не затирайте уже установленный args.Args.VoiceId
пустым значением маски; перед присвоением ent.Comp.VoiceId проверяйте, что само
значение действительно задано (не null/пустая строка/значение по умолчанию) и
только тогда заменяйте args.Args.VoiceId; иначе пропустите присвоение, чтобы
сохранить исходный голос (ссылки: OnSpeakerVoiceTransform, VoiceMaskComponent,
InventoryRelayedEvent<TransformSpeakerVoiceEvent>, args.Args.VoiceId,
ent.Comp.VoiceId).

In `@Content.Shared/Humanoid/HumanoidProfileComponent.cs`:
- Around line 28-32: Поле Voice в HumanoidProfileComponent помечено только
[DataField("voice")] и потому не синхронизируется; добавьте атрибут
AutoNetworkedField рядом с DataField (например [DataField("voice"),
AutoNetworkedField]) к объявлению public ProtoId<TTSVoicePrototype> Voice =
TTSConfig.DefaultVoice; чтобы поведение соответствовало другим полям (Gender,
Sex, Age, Species) и значение голоса реплицировалось клиентам.

In `@Content.Shared/Humanoid/HumanoidProfileExportV1.cs`:
- Around line 65-67: В V1 DTO (HumanoidProfileExportV1) добавлено поле Voice, но
ToV2() сейчас всегда прокидывает его дальше — нужно добавить явную
проверку/фолбек: в методе ToV2() (и аналогично в месте на строках ~89-91)
определить, валиден ли ProtoId<TTSVoicePrototype> Voice (не равен
default/пустому/невалидному), и только тогда записывать его в V2-профиль; если
значение отсутствует — либо не устанавливать поле в V2, либо подставлять
допустимый дефолт (например null/отсутствующий идентификатор) чтобы не собирать
профиль с невалидным голосом. Упомяните конкретно поле Voice и метод ToV2() при
правке.

In `@Content.Shared/Preferences/HumanoidCharacterProfile.cs`:
- Around line 38-40: MemberwiseEquals and GetHashCode currently ignore the new
Voice property, so update both methods to include Voice in their comparisons and
hash computation: inside MemberwiseEquals (the method that compares two
HumanoidCharacterProfile instances) add a comparison for Voice (e.g.,
string.Equals(this.Voice, other.Voice, StringComparison.Ordinal)) alongside the
existing field comparisons, and in GetHashCode include Voice when combining
hashes (the same hash-combining logic used for other fields) so changes to Voice
affect equality and the resulting hash; also ensure the same change is applied
where similar logic exists around the region referenced (lines ~321-324) to keep
all equality/hash logic consistent.

---

Minor comments:
In `@Content.Client/_Art/TTS/HumanoidProfileEditor.TTS.cs`:
- Around line 50-55: The code in HumanoidProfileEditor.TTS.cs may throw
IndexOutOfRangeException when _voiceList is empty because it accesses
_voiceList[firstVoiceChoiceId]; before calling VoiceButton.TrySelectId and
SetVoice, guard against an empty list (check _voiceList.Count > 0 or
_voiceList.Any()) and only call VoiceButton.TrySelectId(…) or SetVoice(…) when
there is at least one element; update the block containing voiceChoiceId,
firstVoiceChoiceId, VoiceButton.TrySelectId and SetVoice to skip
selection/setting (or use a safe default) when _voiceList is empty.

In `@Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs`:
- Around line 196-200: The UpdateTTSVoicesControls method can be called when TTS
is disabled which leaves _voiceList empty and yields an inconsistent UI; either
add a guard before calling it in SetProfile and OnReset (check
configurationManager.GetCVar(ArtCVars.TTSClientEnabled) or TTSContainer.Visible)
or change UpdateTTSVoicesControls to be a safe no-op when TTS is off (early
return if TTS disabled or if _voiceList is null/empty) so it does not rely on
InitializeVoice having run; update references to InitializeVoice, SetProfile,
OnReset, _voiceList, and VoiceButton accordingly.

In `@Content.Client/Options/UI/Tabs/AudioTab.xaml`:
- Line 11: В XAML убери жёстко закодированные русские строки и замени их на
локализуемые ресурсы, используя шаблон {Loc 'KeyName'} для элементов
SliderVolumeTts (Title) и соответствующего чекбокса/элемента "Включить ТТС"
(примерно на строке с именем, содержащим Tts или EnableTts); добавь уникальные
ключи (например Audio.VolumeTts и Audio.EnableTts) в файлы локализации и обнови
ресурсы перевода, чтобы сохранить консистентность с остальными UI-элементами.

In `@Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml.cs`:
- Around line 90-92: Reset the selector before attempting to re-select the voice
so stale UI state is cleared: set the VoiceSelector selection to an
empty/fallback state (e.g., SelectedIndex = -1 or clear selection) prior to
calling _voices.FindIndex(...), then if voiceIdx != -1 call
VoiceSelector.Select(voiceIdx); ensure you reference the existing
variables/methods (_voices, voiceIdx, VoiceSelector.Select) and add the explicit
reset when the voice is not found so the UI shows a cleared/fallback choice
instead of the previous selection.

In `@Content.Server/_Art/TTS/TTSSystem.cs`:
- Around line 66-72: The handler OnClientOptionTTS is marked async without any
await and mutates the shared _ignoredRecipients non-thread-safely; remove the
async modifier and make the method synchronous (change signature
OnClientOptionTTS to a plain void handler) and protect updates to
_ignoredRecipients by either replacing the collection with a concurrent
structure (e.g., use a ConcurrentDictionary/ConcurrentHashSet pattern keyed by
args.SenderSession) or wrap Add/Remove in a lock using a dedicated private
readonly object (e.g., lock(_ignoredRecipientsLock) { ... }); ensure all code
paths that modify or read _ignoredRecipients use the chosen synchronization
approach.

In `@Content.Shared/_Art/CVars/ArtCVars.cs`:
- Around line 17-21: Комментарий XML над объявлением public static readonly
CVarDef<bool> TTSClientEnabled некорректен (копипаст — "URL of the TTS server
API"); замените его на корректное описание состояния этой булевой переменной,
например кратко поясняющее, что переменная включает/отключает TTS-клиент
(например: "Enable/disable the TTS client" или на русском "Включает/выключает
TTS-клиент"), сохранив формат <summary>... </summary> над TTSClientEnabled и не
меняя саму сигнатуру CVarDef<bool>.

In `@Resources/_Art/TTS/tts-voices.yml`:
- Around line 93-112: The YAML currently lists synthetic TTS voice identifiers
for "Джо Байден" (speaker/id: biden), "Барак Обама" (speaker/id: obama) and
"Дональд Трамп" (speaker/id: trump) without clarifying they are
synthetic/parody; add a brief comment near these entries (or at the top of
Resources/_Art/TTS/tts-voices.yml) stating that these are synthetic TTS voices
generated from text (not recorded audio), intended for parody/entertainment in a
non-commercial/game context and not representations of real recorded
performances, to reduce legal ambiguity and clarify intent.

---

Nitpick comments:
In `@Content.Client/_Art/TTS/ContentAudioSystem.cs`:
- Around line 1-8: The constant TtsMultiplier = 3f in the partial class
ContentAudioSystem is a magic number and should be documented; add a short
explanatory comment or XML doc summary above the TtsMultiplier declaration
(referencing ContentAudioSystem and TtsMultiplier) that describes what the
multiplier means, its units/scale, why the value 3f was chosen, and any
implications for audio timing/volume so future maintainers understand its
purpose.

In `@Content.Client/_Art/TTS/HumanoidProfileEditor.TTS.cs`:
- Around line 38-48: The logic around firstVoiceChoiceId is redundant: it's
initialized to 1 then immediately set to i on the first loop iteration; simplify
by initializing firstVoiceChoiceId to 0 (or remove it and use 0 directly) and
drop the if check inside the loop. Update the loop over _voiceList (where
VoiceButton.AddItem(name, i) is called) to rely on a default index of 0 for the
first voice choice and remove the conditional that references
firstVoiceChoiceId.

In `@Content.Client/_Art/TTS/TTSSystem.cs`:
- Around line 45-50: The field _toDelete is declared internal but is only used
inside this class; change its accessibility to private by renaming its
declaration from internal List<NetEntity> _toDelete = new(); to private
List<NetEntity> _toDelete = new(); and run a quick search for external
references to _toDelete to ensure no external usages break (if any exist, either
keep internal or update those call sites).
- Around line 175-185: В методе AdjustVolume используется "магическое" число
3.0f при вычислении громкости (var volume = MinimalVolume +
SharedAudioSystem.GainToVolume(_volume * 3.0f)); вынесите этот множитель в
именованную константу (например VolumeGainMultiplier или DefaultGainScale) в
области класса рядом с MinimalVolume/WhisperFade, замените 3.0f на константу и
добавьте короткий комментарий о назначении константы; проверьте, что
AdjustVolume, _volume, SharedAudioSystem.GainToVolume и WhisperFade по-прежнему
корректно работают с новой константой.
- Line 158: Объявление MemoryStream при вызове _audioLoader.LoadAudioWav(new
MemoryStream(ev.Data)) не обёрнуто в using; оберните создание MemoryStream в
using (или используйте using var) чтобы гарантировать Dispose после
использования; местоположения для правки: вызов в методе в классе TTSSystem.cs
где создаётся переменная audioStream и вызывается метод LoadAudioWav на
_audioLoader.

In `@Content.Server/_Art/TTS/TTSSystem.cs`:
- Around line 74-89: OnRequestPreviewTTS is an async void handler so exceptions
can escape; wrap the method body in a try/catch that catches Exception (or a
more specific set) and logs the error (including context like ev.VoiceId,
args.SenderSession) before returning, ensuring failures from GenerateTTS,
_prototypeManager.TryIndex, or RaiseNetworkEvent are logged; keep existing early
returns (RateLimitStatus check) but put the remaining logic (previewText
selection, await GenerateTTS, and RaiseNetworkEvent) inside the try block and
log the exception in the catch (include method name OnRequestPreviewTTS and
relevant identifiers).

In `@Content.Server/_Art/TTS/TTSSystem.SSML.cs`:
- Line 21: Переименуй перечисление-элемент PitchVerylow в PitchVeryLow для
соответствия PascalCase: обнови определение enum (в файле TTSSystem.SSML.cs) и
все места использования этого значения (ссылки в коде, сравнения,
сериализация/десериализация и тесты) так, чтобы вместо PitchVerylow
использовалось PitchVeryLow; не забудь также обновить любые строковые
представления или документацию, зависящие от имени.

In `@Content.Server/Radio/EntitySystems/HeadsetSystem.cs`:
- Around line 113-123: The code duplicates Transform(uid).ParentUid even though
parent was already computed; inside the TryComp(actor) block where you create
the TTSRadioPlayEvent and call RaiseLocalEvent, replace the repeated
Transform(uid).ParentUid call with the existing parent variable (computed
earlier), keeping the rest of the logic (creating
TTSRadioPlayEvent(args.Message, voice, GetNetEntity(uid),
GetNetEntity(args.MessageSource)) and calling RaiseLocalEvent(parent, ev))
intact so you no longer recompute the transform.

In `@Content.Shared/_Art/CVars/ArtCVars.cs`:
- Around line 41-45: The default TTSVolume CVar is set to 0f which mutes TTS by
default; update the CVar default in the TTSVolume declaration (the
CVarDef.Create call for TTSVolume) to a sensible audible default such as 0.5f
(or 1.0f if you prefer full volume) so users have working TTS out of the box
while still allowing them to lower it via the CVar.

In `@Content.Shared/_Art/TTS/TTSRadioPlayEvent.cs`:
- Around line 1-3: Удалите неиспользуемые директивы using из файла
TTSRadioPlayEvent.cs: уберите строки с using Content.Shared.Speech, using
Robust.Shared.Prototypes и using Content.Shared.Inventory; оставьте только
необходимые пространства имен и сохраните объявление класса/структуры
TTSRadioPlayEvent без изменений.

In `@Content.Shared/_Art/TTS/TTSVoicePrototype.cs`:
- Line 1: Удалите неиспользуемую директиву использования `using
Content.Shared.Humanoid;` из файла — она не применяется в коде класса/файла
`TTSVoicePrototype` и должна быть удалена чтобы устранить предупреждение о
неиспользуемом импорте.

In `@Content.Shared/VoiceMask/VoiceMaskComponent.cs`:
- Around line 18-22: Поле VoiceId в VoiceMaskComponent объявлено как string;
замените тип на ProtoId<TTSVoicePrototype> для согласованности с
HumanoidProfileComponent::Voice, обновите инициализацию (вместо
TTSConfig.DefaultVoice присвойте new
ProtoId<TTSVoicePrototype>(TTSConfig.DefaultVoice) или эквивалентный
метод-конструктор), добавьте необходимый using/импорт для ProtoId и
TTSVoicePrototype и проверьте/скорректируйте все места, где читают/пишут VoiceId
(например сравнения или сериализация), чтобы работать с
ProtoId<TTSVoicePrototype> вместо string.

In `@Resources/_Art/TTS/tts-voices.yml`:
- Around line 1-37: The file contains two commented generator scripts (an Emacs
Lisp defun f block and a Python "#!/usr/bin/env python3" voice generator)
embedded in Resources/_Art/TTS/tts-voices.yml; remove these commented blocks
from the YAML and either delete them or move them into a proper scripts folder
(e.g., /scripts) as standalone files (retain the Emacs Lisp function name "f"
and the Python generator that reads "voices.json" when moving so they can be
located and restored if needed), and ensure the YAML contains only resource data
after the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b8468225-fb6c-42f9-956d-f7d627809cf0

📥 Commits

Reviewing files that changed from the base of the PR and between 576d808 and 1d18c7d.

📒 Files selected for processing (43)
  • Content.Client/Lobby/UI/HumanoidProfileEditor.Appearance.cs
  • Content.Client/Lobby/UI/HumanoidProfileEditor.xaml
  • Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
  • Content.Client/Options/UI/Tabs/AudioTab.xaml
  • Content.Client/Options/UI/Tabs/AudioTab.xaml.cs
  • Content.Client/VoiceMask/VoiceMaskBoundUserInterface.cs
  • Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml
  • Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml.cs
  • Content.Client/_Art/TTS/ContentAudioSystem.cs
  • Content.Client/_Art/TTS/HumanoidProfileEditor.TTS.cs
  • Content.Client/_Art/TTS/TTSSystem.cs
  • Content.Client/_Art/TTS/VoiceMaskNameChangeWindow.xaml.cs
  • Content.Server.Database/Model.cs
  • Content.Server/Database/ServerDbBase.cs
  • Content.Server/Entry/EntryPoint.cs
  • Content.Server/IoC/ServerContentIoC.cs
  • Content.Server/Preferences/Managers/ServerPreferencesManager.cs
  • Content.Server/Radio/EntitySystems/HeadsetSystem.cs
  • Content.Server/Radio/EntitySystems/RadioSystem.cs
  • Content.Server/Radio/RadioEvent.cs
  • Content.Server/VoiceMask/VoiceMaskSystem.cs
  • Content.Server/_Art/TTS/TTSManager.cs
  • Content.Server/_Art/TTS/TTSSystem.RateLimit.cs
  • Content.Server/_Art/TTS/TTSSystem.SSML.cs
  • Content.Server/_Art/TTS/TTSSystem.Sanitize.cs
  • Content.Server/_Art/TTS/TTSSystem.cs
  • Content.Server/_Art/TTS/VoiceMaskSystem.TTS.cs
  • Content.Shared/Humanoid/HumanoidProfileComponent.cs
  • Content.Shared/Humanoid/HumanoidProfileExportV1.cs
  • Content.Shared/Inventory/InventorySystem.Relay.cs
  • Content.Shared/Preferences/HumanoidCharacterProfile.cs
  • Content.Shared/VoiceMask/SharedVoiceMaskSystem.cs
  • Content.Shared/VoiceMask/VoiceMaskComponent.cs
  • Content.Shared/_Art/CVars/ArtCVars.cs
  • Content.Shared/_Art/TTS/ClientOptionTTSEvent.cs
  • Content.Shared/_Art/TTS/PlayTTSEvent.cs
  • Content.Shared/_Art/TTS/RequestPreviewTTSEvent.cs
  • Content.Shared/_Art/TTS/SharedVoiceMaskSystem.cs
  • Content.Shared/_Art/TTS/TTSComponent.cs
  • Content.Shared/_Art/TTS/TTSConfig.cs
  • Content.Shared/_Art/TTS/TTSRadioPlayEvent.cs
  • Content.Shared/_Art/TTS/TTSVoicePrototype.cs
  • Resources/_Art/TTS/tts-voices.yml

Comment on lines +16 to +20
_voiceList = _prototypeManager
.EnumeratePrototypes<TTSVoicePrototype>()
.OrderBy(o => Loc.GetString(o.Name))
.OrderBy(o => ((o.Gender == "male") ? 0b01 : 0) + ((o.Gender == "female") ? 0b10 : 0))
.ToList();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Ошибка сортировки: второй OrderBy заменяет первый.

Использование двух последовательных .OrderBy() приводит к тому, что вторая сортировка полностью заменяет первую. Для стабильной многоуровневой сортировки следует использовать .ThenBy().

🐛 Предлагаемое исправление
 _voiceList = _prototypeManager
     .EnumeratePrototypes<TTSVoicePrototype>()
     .OrderBy(o => Loc.GetString(o.Name))
-    .OrderBy(o => ((o.Gender == "male") ? 0b01 : 0) + ((o.Gender == "female") ? 0b10 : 0))
+    .ThenBy(o => ((o.Gender == "male") ? 0b01 : 0) + ((o.Gender == "female") ? 0b10 : 0))
     .ToList();

Примечание: Если цель — сначала сортировать по полу, а затем по имени, порядок вызовов нужно поменять местами.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_voiceList = _prototypeManager
.EnumeratePrototypes<TTSVoicePrototype>()
.OrderBy(o => Loc.GetString(o.Name))
.OrderBy(o => ((o.Gender == "male") ? 0b01 : 0) + ((o.Gender == "female") ? 0b10 : 0))
.ToList();
_voiceList = _prototypeManager
.EnumeratePrototypes<TTSVoicePrototype>()
.OrderBy(o => Loc.GetString(o.Name))
.ThenBy(o => ((o.Gender == "male") ? 0b01 : 0) + ((o.Gender == "female") ? 0b10 : 0))
.ToList();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Client/_Art/TTS/HumanoidProfileEditor.TTS.cs` around lines 16 - 20, В
коде формирования _voiceList последовательный вызов .OrderBy() перезаписывает
первую сортировку; замените второе .OrderBy(...) на .ThenBy(...) (или поменяйте
порядок вызовов, если нужно сначала по полю Gender, потом по имени) при
перечислении прототипов через _prototypeManager. Убедитесь, что ссылки на
TTSVoicePrototype.Name (через Loc.GetString) и TTSVoicePrototype.Gender
используются в корректных лямбдах для первичной и вторичной сортировки
соответственно.

Comment on lines +24 to +29
VoiceSelector.OnItemSelected += args =>
{
VoiceSelector.SelectId(args.Id);
if (VoiceSelector.SelectedMetadata != null)
OnVoiceChange?.Invoke((string)VoiceSelector.SelectedMetadata);
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Повторная подписка на событие при каждом вызове ReloadVoices().

Подписка на VoiceSelector.OnItemSelected происходит внутри ReloadVoices(). При повторных вызовах метода обработчик будет добавлен многократно, что приведёт к многократным вызовам OnVoiceChange.

🐛 Предлагаемое исправление

Перенесите подписку в конструктор или добавьте проверку:

+private bool _eventSubscribed;
+
 private void ReloadVoices()
 {
     if (_cfg is null)
         return;
     TTSContainer.Visible = _cfg.GetCVar(ArtCVars.TTSClientEnabled);
     if (!_cfg.GetCVar(ArtCVars.TTSClientEnabled))
         return;
-    VoiceSelector.OnItemSelected += args =>
-    {
-        VoiceSelector.SelectId(args.Id);
-        if (VoiceSelector.SelectedMetadata != null)
-            OnVoiceChange?.Invoke((string)VoiceSelector.SelectedMetadata);
-    };
+    if (!_eventSubscribed)
+    {
+        VoiceSelector.OnItemSelected += args =>
+        {
+            VoiceSelector.SelectId(args.Id);
+            if (VoiceSelector.SelectedMetadata != null)
+                OnVoiceChange?.Invoke((string)VoiceSelector.SelectedMetadata);
+        };
+        _eventSubscribed = true;
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Client/_Art/TTS/VoiceMaskNameChangeWindow.xaml.cs` around lines 24 -
29, Подписка на VoiceSelector.OnItemSelected внутри ReloadVoices() приводит к
множественным обработчикам при повторных вызовах; переместите подписку в
конструктор класса или гарантируйте идемпотентность (например, отписаться перед
новой подпиской или проверять, что обработчик ещё не добавлен) и оставьте внутри
обработчика вызовы VoiceSelector.SelectId(args.Id) и проверку
VoiceSelector.SelectedMetadata с вызовом
OnVoiceChange?.Invoke((string)VoiceSelector.SelectedMetadata).

Comment on lines +30 to +34
_voices = _proto
.EnumeratePrototypes<TTSVoicePrototype>()
.OrderBy(o => Loc.GetString(o.Name))
.OrderBy(o => ((o.Gender == "male") ? 0b01 : 0) + ((o.Gender == "female") ? 0b10 : 0))
.ToList();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Неправильная цепочка сортировки: OrderBy().OrderBy() вместо OrderBy().ThenBy().

Второй вызов OrderBy() полностью заменяет первую сортировку вместо вторичной сортировки. Используйте ThenBy() для сохранения первичной сортировки по имени.

🐛 Предлагаемое исправление
 _voices = _proto
     .EnumeratePrototypes<TTSVoicePrototype>()
-    .OrderBy(o => Loc.GetString(o.Name))
-    .OrderBy(o => ((o.Gender == "male") ? 0b01 : 0) + ((o.Gender == "female") ? 0b10 : 0))
+    .OrderBy(o => ((o.Gender == "male") ? 1 : 0) + ((o.Gender == "female") ? 2 : 0))
+    .ThenBy(o => Loc.GetString(o.Name))
     .ToList();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_voices = _proto
.EnumeratePrototypes<TTSVoicePrototype>()
.OrderBy(o => Loc.GetString(o.Name))
.OrderBy(o => ((o.Gender == "male") ? 0b01 : 0) + ((o.Gender == "female") ? 0b10 : 0))
.ToList();
_voices = _proto
.EnumeratePrototypes<TTSVoicePrototype>()
.OrderBy(o => ((o.Gender == "male") ? 1 : 0) + ((o.Gender == "female") ? 2 : 0))
.ThenBy(o => Loc.GetString(o.Name))
.ToList();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Client/_Art/TTS/VoiceMaskNameChangeWindow.xaml.cs` around lines 30 -
34, The current sorting chain on _voices uses two OrderBy calls so the second
replaces the first; change the second OrderBy to ThenBy so the list is primarily
ordered by Loc.GetString(o.Name) and secondarily by the gender expression —
update the LINQ chain that starts with
_proto.EnumeratePrototypes<TTSVoicePrototype>() and the two OrderBy(...) calls
(the one ordering by name and the one ordering by the gender bitmask) to use
ThenBy for the secondary sort.

public string Sex { get; set; } = null!;
public string Gender { get; set; } = null!;
public string Species { get; set; } = null!;
public string Voice { get; set; } = null!; // Art-TTS
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Поиск миграций, связанных с Voice или TTS
fd -e cs . | xargs rg -l -i "voice|tts" | head -20
rg -n "Voice" --type cs -g "*Migration*"

Repository: ss14-art/open-space

Length of output: 1149


🏁 Script executed:

# Find migration files and directories
fd -t d "Migrations" --hidden
find . -type f -name "*Migration*.cs" | head -20
find . -type f -path "*/Migrations/*.cs" | head -30

Repository: ss14-art/open-space

Length of output: 2582


🏁 Script executed:

# Check the Model.cs file to confirm Voice property
head -360 Content.Server.Database/Model.cs | tail -40

Repository: ss14-art/open-space

Length of output: 1799


🏁 Script executed:

# Search for any recent migration files mentioning database schema changes
fd -t f -e cs . -path "*/Migrations/*" | xargs ls -lt 2>/dev/null | head -20

Repository: ss14-art/open-space

Length of output: 1509


🏁 Script executed:

# Find the most recent migration files
ls -lat Content.Server.Database/Migrations/Postgres/ | head -30

Repository: ss14-art/open-space

Length of output: 2574


🏁 Script executed:

# Check the latest migration file to see what columns it adds
tail -100 "$(ls -t Content.Server.Database/Migrations/Postgres/*.cs | grep -v Designer | head -1)"

Repository: ss14-art/open-space

Length of output: 4658


🏁 Script executed:

# Search for "Voice" in any migration files to see if it's mentioned
rg "Voice" Content.Server.Database/Migrations/Postgres/

Repository: ss14-art/open-space

Length of output: 45


Требуется миграция базы данных для нового столбца Voice.

Добавление не-nullable свойства Voice в модель Profile требует соответствующей миграции Entity Framework. Проверка показала, что ни один файл миграции не содержит упоминания о столбце Voice, что приведёт к несоответствию схемы базы данных модели C#. Без миграции существующие записи профилей будут вызывать ошибки при попытке чтения, так как столбец отсутствует в базе данных.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Server.Database/Model.cs` at line 334, Добавлено не-nullable свойство
Profile.Voice, но нет миграции EF для добавления соответствующего столбца в БД;
создайте и примените миграцию Entity Framework которая добавляет столбец Voice в
таблицу профилей (либо сделать свойство nullable и миграцию для
nullable-столбца, либо добавить non-nullable столбец с подходящим значением по
умолчанию), затем выполнить обновление БД (например через «dotnet ef migrations
add» и «dotnet ef database update») чтобы схема БД соответствовала модели
Profile.Voice.

Comment on lines +40 to +42
private readonly Dictionary<string, byte[]> _cache = new();
private readonly Dictionary<string, SemaphoreSlim> _semaphores = new();
private readonly List<string> _cacheKeysSeq = new();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Find and read the TTSManager.cs file
fd TTSManager.cs --type f

Repository: ss14-art/open-space

Length of output: 100


🏁 Script executed:

# Read the TTSManager.cs file to understand the code structure
cat -n Content.Server/_Art/TTS/TTSManager.cs

Repository: ss14-art/open-space

Length of output: 9249


🏁 Script executed:

# Verify if there are any lock mechanisms or thread-safe collections in the class
rg -n "lock|Monitor|ReaderWriterLock|ConcurrentDictionary|Interlocked" Content.Server/_Art/TTS/TTSManager.cs

Repository: ss14-art/open-space

Length of output: 45


🏁 Script executed:

# Check if there's a using statement or disposal pattern for HttpResponseMessage or CancellationTokenSource
rg -n "using.*HttpResponseMessage|using.*CancellationTokenSource|response\.Dispose|cts\.Dispose" Content.Server/_Art/TTS/

Repository: ss14-art/open-space

Length of output: 45


Эта дедупликация не потокобезопасна.

Строки 75-76: _semaphores.GetValueOrDefault(...) и _semaphores[cacheKey] = ... не атомарны. Два одновременных запроса для одного cacheKey могут создать разные SemaphoreSlim — оба ждут на своих объектах и независимо уходят в API, что нарушает дедупликацию.

Строки 117-123: Параллельные модификации _cache и _cacheKeysSeq без синхронизации вызывают гонки.

Строки 146-147: Удаление семафора в finally перед завершением — новый запрос может создать новый семафор, пока предыдущий ещё в полёте.

Строки 151-155: ResetCache() не очищает _semaphores, что создаёт состояние рассогласованности.

Дополнительно, строка 93 (CancellationTokenSource) и строка 98 (HttpResponseMessage) не освобождаются, оба реализуют IDisposable.

Also applies to: 75-76, 117-123, 146-147, 151-155

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Server/_Art/TTS/TTSManager.cs` around lines 40 - 42, The
deduplication is not thread-safe: replace the Dictionary<string, SemaphoreSlim>
_semaphores usage with a ConcurrentDictionary and use GetOrAdd (or
ConcurrentDictionary<string, SemaphoreSlim>.GetOrAdd) to ensure a single
SemaphoreSlim per cacheKey atomically (fix the current
_semaphores.GetValueOrDefault(...) + _semaphores[cacheKey] = ... race). Protect
mutations of _cache and _cacheKeysSeq by either switching to concurrent
collections (e.g., ConcurrentDictionary for _cache and a thread-safe structure
for ordering) or by serializing access with a private lock object so the
add/evict sequence is atomic (fix parallel modifications at the
_cache/_cacheKeysSeq block). When cleaning up the semaphore in the finally
block, only remove/dispose it if you successfully removed the exact instance
(use ConcurrentDictionary.TryRemove with the expected instance) so you don't
race with a new requester. Update ResetCache() to also clear/ dispose
_semaphores to avoid inconsistent state. Finally, ensure CancellationTokenSource
and HttpResponseMessage are disposed (use using statements or explicit Dispose
in finally) where they are created.

Comment on lines +6 to +14
private string ToSsmlText(string text, SoundTraits traits = SoundTraits.None)
{
var result = text;
if (traits.HasFlag(SoundTraits.RateFast))
result = $"<prosody rate=\"fast\">{result}</prosody>";
if (traits.HasFlag(SoundTraits.PitchVerylow))
result = $"<prosody pitch=\"x-low\">{result}</prosody>";
return $"<speak>{result}</speak>";
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Отсутствует экранирование XML-символов во входном тексте.

Параметр text напрямую встраивается в SSML XML без экранирования специальных символов (<, >, &, ", '). Если пользовательский текст содержит эти символы, результат будет невалидным SSML или потенциально уязвимым для инъекции.

🐛 Предлагаемое исправление с экранированием
+using System.Security;
+
 private string ToSsmlText(string text, SoundTraits traits = SoundTraits.None)
 {
-    var result = text;
+    var result = SecurityElement.Escape(text);
     if (traits.HasFlag(SoundTraits.RateFast))
         result = $"<prosody rate=\"fast\">{result}</prosody>";
     if (traits.HasFlag(SoundTraits.PitchVerylow))
         result = $"<prosody pitch=\"x-low\">{result}</prosody>";
     return $"<speak>{result}</speak>";
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Server/_Art/TTS/TTSSystem.SSML.cs` around lines 6 - 14, The
ToSsmlText method embeds the raw text into SSML without escaping XML-special
characters; update it to escape the input before wrapping with prosody/speak
(e.g., use System.Security.SecurityElement.Escape or create an XText to produce
a safe string) so '<', '>', '&', '"' and '\'' are encoded; apply the escape to
the initial text variable (used by ToSsmlText) and then preserve the existing
SoundTraits checks (RateFast, PitchVerylow) and prosody wrapping logic so the
escaped text is what gets inserted into the <prosody> and final <speak> output.

Comment on lines +16 to +19
private void OnSpeakerVoiceTransform(Entity<VoiceMaskComponent> ent, ref InventoryRelayedEvent<TransformSpeakerVoiceEvent> args)
{
args.Args.VoiceId = ent.Comp.VoiceId;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Не затирайте исходный голос пустым значением маски.

Если у маски VoiceId не выбран, обработчик все равно перезаписывает уже вычисленный args.Args.VoiceId. В итоге надетая маска без собственного TTS-голоса сбрасывает голос персонажа вместо сохранения исходного.

💡 Предлагаемое исправление
 private void OnSpeakerVoiceTransform(Entity<VoiceMaskComponent> ent, ref InventoryRelayedEvent<TransformSpeakerVoiceEvent> args)
 {
-    args.Args.VoiceId = ent.Comp.VoiceId;
+    if (!string.IsNullOrEmpty(ent.Comp.VoiceId))
+        args.Args.VoiceId = ent.Comp.VoiceId;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private void OnSpeakerVoiceTransform(Entity<VoiceMaskComponent> ent, ref InventoryRelayedEvent<TransformSpeakerVoiceEvent> args)
{
args.Args.VoiceId = ent.Comp.VoiceId;
}
private void OnSpeakerVoiceTransform(Entity<VoiceMaskComponent> ent, ref InventoryRelayedEvent<TransformSpeakerVoiceEvent> args)
{
if (!string.IsNullOrEmpty(ent.Comp.VoiceId))
args.Args.VoiceId = ent.Comp.VoiceId;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Server/_Art/TTS/VoiceMaskSystem.TTS.cs` around lines 16 - 19, В
обработчике OnSpeakerVoiceTransform для Entity<VoiceMaskComponent> не затирайте
уже установленный args.Args.VoiceId пустым значением маски; перед присвоением
ent.Comp.VoiceId проверяйте, что само значение действительно задано (не
null/пустая строка/значение по умолчанию) и только тогда заменяйте
args.Args.VoiceId; иначе пропустите присвоение, чтобы сохранить исходный голос
(ссылки: OnSpeakerVoiceTransform, VoiceMaskComponent,
InventoryRelayedEvent<TransformSpeakerVoiceEvent>, args.Args.VoiceId,
ent.Comp.VoiceId).

Comment on lines +28 to +32

// Art-TTS Start
[DataField("voice")]
public ProtoId<TTSVoicePrototype> Voice = TTSConfig.DefaultVoice; // HumanoidCharacterProfile
// Art-TTS End
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Отсутствует атрибут AutoNetworkedField для поля Voice.

Поле Voice имеет только [DataField("voice")], но не имеет [AutoNetworkedField]. Все остальные поля в этом компоненте (Gender, Sex, Age, Species) используют оба атрибута [DataField, AutoNetworkedField]. Без AutoNetworkedField значение голоса не будет синхронизироваться с клиентами.

🐛 Предлагаемое исправление
     // Art-TTS Start
-    [DataField("voice")]
+    [DataField("voice"), AutoNetworkedField]
     public ProtoId<TTSVoicePrototype> Voice = TTSConfig.DefaultVoice; // HumanoidCharacterProfile
     // Art-TTS End
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Art-TTS Start
[DataField("voice")]
public ProtoId<TTSVoicePrototype> Voice = TTSConfig.DefaultVoice; // HumanoidCharacterProfile
// Art-TTS End
// Art-TTS Start
[DataField("voice"), AutoNetworkedField]
public ProtoId<TTSVoicePrototype> Voice = TTSConfig.DefaultVoice; // HumanoidCharacterProfile
// Art-TTS End
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Shared/Humanoid/HumanoidProfileComponent.cs` around lines 28 - 32,
Поле Voice в HumanoidProfileComponent помечено только [DataField("voice")] и
потому не синхронизируется; добавьте атрибут AutoNetworkedField рядом с
DataField (например [DataField("voice"), AutoNetworkedField]) к объявлению
public ProtoId<TTSVoicePrototype> Voice = TTSConfig.DefaultVoice; чтобы
поведение соответствовало другим полям (Gender, Sex, Age, Species) и значение
голоса реплицировалось клиентам.

Comment on lines +65 to +67
// Art-TTS Start
[DataField]
public ProtoId<TTSVoicePrototype> Voice;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Не ломайте импорт старых V1-профилей.

В DTO версии 1 появилось новое поле Voice, а ToV2() теперь безусловно прокидывает его дальше. Старые экспортированные V1-файлы этого поля не содержат, поэтому сюда придёт default ProtoId, и миграция соберёт профиль с невалидным голосом. Нужен явный fallback для отсутствующего значения либо отдельная версия формата.

Also applies to: 89-91

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Shared/Humanoid/HumanoidProfileExportV1.cs` around lines 65 - 67, В
V1 DTO (HumanoidProfileExportV1) добавлено поле Voice, но ToV2() сейчас всегда
прокидывает его дальше — нужно добавить явную проверку/фолбек: в методе ToV2()
(и аналогично в месте на строках ~89-91) определить, валиден ли
ProtoId<TTSVoicePrototype> Voice (не равен default/пустому/невалидному), и
только тогда записывать его в V2-профиль; если значение отсутствует — либо не
устанавливать поле в V2, либо подставлять допустимый дефолт (например
null/отсутствующий идентификатор) чтобы не собирать профиль с невалидным
голосом. Упомяните конкретно поле Voice и метод ToV2() при правке.

Comment on lines +38 to +40
// Art-TTS Start
[DataField]
public string Voice { get; set; } = TTSConfig.DefaultVoice;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Voice нужно включить в сравнение и хеш профиля.

После добавления этого поля MemberwiseEquals() и GetHashCode() ниже по файлу все еще его игнорируют. Изменение только голоса тогда выглядит как отсутствие изменений и может не сохраниться, а Equals/GetHashCode перестают описывать фактическое состояние профиля.

💡 Что нужно добавить ниже по файлу
 public bool MemberwiseEquals(HumanoidCharacterProfile other)
 {
     if (Name != other.Name) return false;
     if (Age != other.Age) return false;
     if (Sex != other.Sex) return false;
     if (Gender != other.Gender) return false;
     if (Species != other.Species) return false;
     if (PreferenceUnavailable != other.PreferenceUnavailable) return false;
     if (SpawnPriority != other.SpawnPriority) return false;
     if (!_jobPriorities.SequenceEqual(other._jobPriorities)) return false;
     if (!_antagPreferences.SequenceEqual(other._antagPreferences)) return false;
     if (!_traitPreferences.SequenceEqual(other._traitPreferences)) return false;
     if (!Loadouts.SequenceEqual(other.Loadouts)) return false;
     if (FlavorText != other.FlavorText) return false;
+    if (Voice != other.Voice) return false;
     return Appearance.Equals(other.Appearance);
 }

 public override int GetHashCode()
 {
     var hashCode = new HashCode();
     hashCode.Add(_jobPriorities);
     hashCode.Add(_antagPreferences);
     hashCode.Add(_traitPreferences);
     hashCode.Add(_loadouts);
     hashCode.Add(Name);
     hashCode.Add(FlavorText);
+    hashCode.Add(Voice);
     hashCode.Add(Species);
     hashCode.Add(Age);

Also applies to: 321-324

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Content.Shared/Preferences/HumanoidCharacterProfile.cs` around lines 38 - 40,
MemberwiseEquals and GetHashCode currently ignore the new Voice property, so
update both methods to include Voice in their comparisons and hash computation:
inside MemberwiseEquals (the method that compares two HumanoidCharacterProfile
instances) add a comparison for Voice (e.g., string.Equals(this.Voice,
other.Voice, StringComparison.Ordinal)) alongside the existing field
comparisons, and in GetHashCode include Voice when combining hashes (the same
hash-combining logic used for other fields) so changes to Voice affect equality
and the resulting hash; also ensure the same change is applied where similar
logic exists around the region referenced (lines ~321-324) to keep all
equality/hash logic consistent.

@ReWAFFlution
Copy link
Copy Markdown
Member Author

@cryals сделай миграцию пожалуйста, я уже закончу остальное. Буду тестить уже самостоятельно, если всё не полетит как обычно. Пока что буду заниматься другими задачами.

@ReWAFFlution ReWAFFlution marked this pull request as draft March 27, 2026 02:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant